说明:
本文只总结了JavaScript数组在web端的行为,不包括NodeJs端的行为。
本文不涉及类型化数组(TypedArray)的讨论、总结。

一、什么是数组

数组的定义

数组,是有序的元素序列。数组中的每个值称为数组的一个元素,数组中每个元素都有一个位置,这个位置被称为索引(下标),数组的索引是从0开始的。

JavaScript语言中的数组

每种语言都有数组这种数据结构,但是JavaScript语言中的数组和其他语言有很大的不同,主要体现在:

1、本质上数组是一种类列表对象
typeof([]) === 'object' // true

上面的结果说明typeof运算符认为数组的类型就是对象,数组的原型中提供了遍历和修改元素的相关操作。
所以我们可以使用一些对象的方法,比如使用Object.keys返回数组所有的键名:

var list = ['a', 'b', 'c'];
Object.keys(list); // ["0", "1", "2"]

因为JavaScript语言规定对象的属性名必须是字符串,所以我们看上面的执行结果中都是字符串类型的数字,之所以可以用数值读取,是因为非字符串的键名会被转为字符串,所以list[1]list['1']是一样的。

在JavaScript语言中,对象有两种读取成员的方法:“点”结构(object.key)和方括号结构(object[key])。但是,对于数值的属性名,不能使用点结构,所以我们一般使用数组时都是方括号结构,比如list[0]。数组也可以添加非整数数值类的属性,比如:

var list = ['a', 'b', 'c'];
list.name = 'haha';
list['age'] = '25';
Object.keys(list); // ["0", "1", "2", "name", "age"]
console.log(list.length); // 3

可以看到上面的数组增加了nameage两个属性,并且包含在了Object.keys返回的属性名中,但是非整数数值类的属性不会影响数组的length值。

另外,我们使用能转换成整数数值类属性名(索引),那么会先将该数值转换成整数,比如:

var list = ['a', 'b', 'c'];
list[1.000] = 'bb';
console.log(list[1]); // 'bb'
list[1.01] = 'dddd'; // 1.01不能转换成整数,所以会生成一个新的属性
console.log(list); // ["a", "bb", "c", 1.01: "dddd"]
list['1.00'] = 'eeee'; // 字符串型也不会转换成整数,也会生成新的属性
console.log(list); // ["a", "bb", "c", 1.01: "dddd", 1.00: "eeee"]
console.log(list.length); // 3
2、数组的长度可以动态改变

每个数组都有一个length属性,在JavaScript语言中length属性值是 0 至 232-1 之间的整数,并且不是只读的,而是可以手动修改的,例如我们经常将一个数组的长度置为0,来达到清空数组的目的:

var list = [1, 2, 3, 4, 5];
list.length = 0;
console.log(list); // []

那么我们增大数组的 length 会怎么样?这引出了数组中 length 和索引之间的关系

JavaScript中数组的length值并不完全等于数组中的元素数量,实际上在默认的情况下length属性的值只是保证大于索引值中的最大值。

当我们减小数组的length值时,数组就会舍弃那些小于和等于length值的索引和数据,当我们增加数组的length值时,因为本身就符合length属性值的规则,所以数组除了length值外,元素“不会发生变化”:

var list = [1, 2, 3, 4, 5];
list.length = 10;
console.log(list); // (10) [1, 2, 3, 4, 5, empty × 5]

如上面结果所示,当length增加到10,打印数组中length为10,元素中有5个empty元素,这就是说数组中有5个“空气”元素,使用 in 运算符 和 Object.keys方法打印索引时会发现5,6,7,8,9这几个索引并不存在:

4 in list // true
5 in list // false
Object.keys(list) // ["0", "1", "2", "3", "4"]

像上面这种存在“空气”的数组叫做 “稀疏数组(sparse array)”,稀疏数组的索引不会持续的被创建。与稀疏数组对立的为“密集数组(dense array)”,密集数组的索引会被持续的创建,并且其元素的数量等于其长度。

3、同一个数组中可以存储不同类型的数据

像Java等强类型的编程语言中,一个数组只能存放一种类型的数据,但是在JavaScript语言中,一个数组可以存放不同类型的数据:

var list = [];
list[0] = 1;
list[1] = 'a';
list[2] = {key: 'name'};
list[3] = ['aaa'];
list[4] = null;
console.log(list); // [1, "a", {…}, Array(1), null]

上面的代码中,一个数组的5个元素分别是数字、字符串、对象、数组、null

二、数组的创建

数组主要有三种基本的创建方式:字面量方式构造函数方式Array.of()

字面量方式(推荐)

// 创建一个长度为 0 的空数组数组
var array = [];
// 创建一个长度为 3 的数组,并初始化了3 个元素:'red' 'green' 'blue'
var colors = ['red', 'green', 'blue'];
// 创建了一个长度为 4 的数组,数组中每一项的类型都不同
var someArray = ['1', 2, {}, null];

构造函数方式

// 创建一个长度为 0 的空数组
var array = new Array();
//创建一个长度为 5 的数组,每个数组的元素的默认值是 undefined。
var colors = new Array(5);
// 创建一个长度为 3 的数组,并初始化了3 个元素:'red' 'green' 'blue'
var colors = new Array('red', 'green', 'blue');

使用构造函数创建数组对象的时候,new 关键字是可以省略的。 例如:

var colors = Array(5);
注意:使用构造函数如果只传入了一个Number值,则这个值必须 >= 0, 否则会报错。

Array.of()方式

为了弥补new Array()Array()在传入不同个数的参数时输出不一致的问题,ES6版本中新增了Array.of()方法创建数组,不论参数的个数多少,of方法的参数始终为数组的元素

Array.of(7); // [7] 
Array.of(1, 2, 3); // [1, 2, 3]

三、类数组数据

在前端编程中还有一部分数据,它们具有数组的部分特征,但又不是数组、不能使用全部的数组API,我们把这种数据称为“类数组(ArrayLike)”,类数组的定义为:

拥有length属性,并且length >= 0,其它属性(索引)为非负整数或非负整数字符串,不具有数组所具有的方法。

比如:

var al = {'0': 'abc', '1': 'edf', length: 2};

上面的al对象就可以称为类数组对象,要想把类数组转换成数组,一般使用数组的slice方法:

var arr = Array.prototype.slice.call(al);
console.log(arr); // ["abc", "edf"]

另外在ES6版本中新增了Array.from()方法用于将类数组数据和可迭代的对象转换成数组:

var arr = Array.from(al);
console.log(arr); // ["abc", "edf"]

类数组转换成数组的过程中,会创建和length的值相等长度的数组,符合非负整数索引的属性值会填充到相应的位置,其他的不符合规则的属性会被抛弃,比如:

var al = {'1': 'abc', '2': 'edf', 'name': 'haha', length: 5};
var arr = Array.prototype.slice.call(al);
console.log(arr); // [empty, "abc", "edf", empty × 2]
console.log(Object.keys(arr)); // ["1", "2"]

可以看到al对象中的length属性值为5,则转换后的数组长度为5,有'1''2'两个符合规则的索引,所以填充到arr[1]arr[2]的位置,其他位置则为emptyname不符合规则被抛弃了,最终也没有体现在keys中。如果对象中没有符合规则的索引,则数组中的元素全部为empty

在前端编程中经常遇到的类数组数据有函数的arguments对象、DOM元素集字符串,我们都可以先把它们转换数组再调用数组的API,另外在不转换成数组的情况下还有一种方法可以让类数组对象使用数组的API,就是通过call()方法把数组的方法放到对象上面:

function log() {
  Array.prototype.forEach.call(arguments, function (item, i) {
    console.log(i + ' = ' + item);
  });
}

不过这种方式要比数组调用API方式效率低,所以建议先转换成数组后再调用数组API。

四、数组的判定

JavaScript中经常用于判断数据类型的方法主要有typeofinstanceof,文章开始时提到数组是特殊的对象

typeof([]) === typeof({}) // true

所以不能区别数组还是对象,使用instanceof可以判断

var arr = [1, 2, 3];
console.log(arr instanceof Array); // true

但是使用instanceof检测有一个弊端,不能跨页面(iFrame)检测数组,后来为了兼容跨页面的情况,换了一种方式检测:

var arr = [1, 2, 3];
console.log('[object Array]' === Object.prototype.toString.call(arr)); // true

鉴于上面说的各种问题,JavaScript在ECMAScript 5版本中加入了Array.isArray()方法来检测数组,它相比instanceof也兼容了跨页面的情况:

var iframe = document.createElement('iframe');
document.body.appendChild(iframe);
xArray = window.frames[window.frames.length-1].Array;
var arr = new xArray(1,2,3);

console.log(Array.isArray(arr));  // true
console.log(arr instanceof Array); // false

关于instanceof的工作原理可以参看 JavaScript instanceof 运算符深入剖析
关于如何精确的判定数组可以参看 严格判定JavaScript对象是否为数组

五、数组的遍历

遍历数组主要使用forfor...in和ES6新增的for...of三种遍历方式:

var arr = ['a', 'b', 'c'];
arr.name = 'haha';

for (var i = 0, len = arr.length; i < len; i++) {
  console.log(i); // "0", "1", "2"
  console.log(arr[i]); // "a", "b", "c"
}

for (var i in arr) {
  console.log(i); // "0", "1", "2", "name"
  console.log(arr[i]); // "a", "b", "c", "haha"
}

for (var i of arr) {
  console.log(i); // "a", "b", "c"
}

这三种遍历的区别在于,for循环是在遍历循环变量i,把i当作索引后取数组的元素。for...in是遍历数组对象的全部key,所以不但能遍历到索引的元素,还能遍历到额外属性的key。for...of是直接遍历数组对象的元素,也只能遍历到有索引的元素。

关于for...of的详细机制可以参考ES6中的 Iterator 和 for...of循环

大多数时候不推荐使用for...in方式遍历数组,但是如果是稀疏数组,for...in就会比for有一定优势了

var arr = new Array(10); // [empty × 10]
arr[5] = 1; // [empty × 5, 1, empty × 4]

for (var i = 0, len = arr.length; i < len; i++) {
  console.log(i);
}
// 0、1、2、3、4、5、6、7、8、9

for (var key in arr) {
  console.log(key);
}
// 5

可以看到for循环执行了10次,for...in看起来执行一次,这种情况还可以使用for...of或者后面介绍的数组遍历的api方法。

六、数组API

JavaScript语言为数组提供了很多API来操作和遍历数组,根据时间顺序整理如下

ES3版本API:

API名称 功能 是否改变原数组
concat() 连接两个或更多的数组
join() 将数组的元素通过指定的分隔符连接生成一个字符串
pop() 删除并返回数组的最后一个元素
push() 向数组的末尾添加一个或更多元素,并返回新的长度
reverse() 反转数组中元素的顺序
shift() 删除并返回数组的第一个元素
slice() 从某个已有的数组返回选定的元素
sort() 对数组的元素进行排序
splice() 删除元素,并向数组添加新元素
toString() 把数组转换为字符串,并返回结果
toLocaleString() 把数组转换为本地字符串,并返回结果
unshift() 向数组的开头添加一个或更多元素,并返回新的长度
valueOf() 返回数组对象的原始值
在上面这些api中大家应该多留意可以改变原数组的api,尤其是sort、reverse、splice这几个方法,
有时忽略了改变原数组的行为,造成很难排查的问题。
另外使用`pop()`和`shift()`删除元素是影响数组的长度的。
也可以使用`delete`方式删除元素,但这种是不影响长度的,被删除的元素会变成`empty`。
var arr = [1 ,2, 3];
delete arr[1];
console.log(arr); // [1, empty, 3]

ES5新增API:

ES5版本新增的遍历数组的API有一些共同特点:

  1. 第一个参数是函数,对数组的每个元素调用一次该函数
  2. 如果是稀疏数组,对不存在的元素不调用传递的函数
  3. 都不主动修改原始数组(可以通过参数引用修改,但是不推荐)
API名称 功能 是否创建新数组
isArray() 数组判定
forEach() 对数组中的每个元素使用调用传入函数,不需要return
every() 该方法接受一个返回值为布尔类型的函数,对数组中得每个元素使用该函数,如果对于所有的元素,该函数都返回 true, 则该方法返回 true
some() 该方法也接受一个返回值为布尔类型的函数,只要有一个元素使得该函数返回true,该方法就返回 true
reduce() 该方法接受一个函数,返回一个值。该方法会从一个累加值开始,不断对累加值和数组中的后续元素调用该函数,直到数组中的最后一个元素,最后返回得到的累加值
reduceRight() 和reduce()方法功能一致,只不过执行顺序是从右至左
map() 和 forEach() 相似,对数组中的每个元素使用传入函数,区别在于返回一个新的数组,该数组的元素是对原有元素应用传入函数得到的结果
filter() 和 every() 类似,传入一个返回值为布尔类型的函数,不同的是当对数组中红所有元素应用该函数时,结果均为 true 时, 该方法不返回true,而是返回一个新数组,该数组包含应用该函数后结果为true 的元素
indexOf() 返回第一个等于给定元素的索引
lastIndexOf() 返回最后一个等于给定元素的索引

这些新增的遍历API和for循环相比更加简洁和明确,但是大多数方法都不能从后往前遍历数组,对数组本身进行增删元素比较麻烦。虽然可以通过参数引用修改原数组元素甚至增删元素,但是往往会改变遍历的行为,所以使用这类方法时最好不要对原数组进行修改,以forEach为例,MDN上给出的解释为:

forEach 遍历的范围在第一次调用 callback 前就会确定。调用forEach 后添加到数组中的项不会被 callback访问到。如果已经存在的值被改变,则传递给 callback 的值是 forEach 遍历到他们那一刻的值。已删除的项不会被遍历到。如果已访问的元素在迭代时被删除了(例如使用 shift()) ,之后的元素将被跳过。
var words = ["one", "two", "three", "four"];
words.forEach(function(word) {
  console.log(word);
  if (word === "two") {
    words.shift();
  }
});
// one
// two
// four

上面的例子输出"one", "two", "four"。当到达包含值"two"的项时,整个数组的第一个项被移除了,这导致所有剩下的项上移一个位置。因为元素 "four"现在在数组更前的位置,"three"会被跳过。 forEach()不会在迭代之前创建数组的副本。

使用这些遍历的api时大家应该了解每个方法的参数要求和执行逻辑,以便在不同的业务逻辑下使用正确的方法。

ES6新增API:

API名称 功能 是否改变原数组
from() 将类数组和可迭代的对象转换成数组
of() 创建新数组
copyWithin() 将数组的一部分拷贝到其他索引位置并返回,不改变数组长度
fill() 填充数组的元素值
find() 返回第一个符合条件的元素的值
findIndex() 返回第一个符合条件的元素的索引
entries() 返回数组的新的迭代器对象,每项都是包含key/value的数组
keys() 返回数组的新的迭代器对象,每项都是数组的索引
values() 返回数组的新的迭代器对象,每项都是数组的元素值

ES7新增API:

API名称 功能 是否改变原数组
includes() 判断给定元素值是否在数组中

目前我们判断数组是否包含一个元素一般都是使用indexOf(),相比而言includes()更加语义化,同时它的底层实现也不是严格的使用===来判断,这点在NaN的判定上可以体现,但是对象的判断也还是需要是相同的引用

var arr = [NaN];
arr.indexOf(NaN); // -1
arr.includes(NaN); // true

var obj = {a: 1};
var arr = [obj];
arr.indexOf(obj); // 0
arr.includes(obj); // true
arr.indexOf({a: 1}); // -1
arr.includes({a: 1}); // false

关于数组的总结暂时先到这里,关于数组和API更全的说明可以参考MDN的资料:Array


朱德明
1 声望0 粉丝